<?php

/**
 * GXRecord
 *
 * Base class for encasing database records.
 * Extend this class for easy enclosed maintenance of records.
 * This class does not support tables which require more than one column to define a unique record.
 * It's built for columns with a single "id" column (doesn't have to be named "id", though).
 * It also does not automatically handle views. To work with views, define the $sql to pull the view but $table as the base table.
 */
class GXRecord extends GXClass {

	// constants
	const DEFAULT_NODE_NAME	= 'node';		// name of node to use in xml() if __node is not provided
	const NO_KEYS			= 'NO_KEYS';	// do not load or create foreign keys
	const NO_CREATE			= 'NO_CREATE';	// do not create

	// table, node and key defs
	protected $__table;					// the database table we'll write changes (this may be different from $sql)
	protected $__node;					// the name of the node (used in show())
	protected $__key;					// the unique column name
	protected $__sql;					// the sql statement to run to retrieve the record ... needs to include the :key reference
	protected $__create		= null;		// key=>val pairs for column defaults when running this::create
	protected $__foreign	= null;		// holds foreign keys and their GXRecord objects; defined as column=>class (class is replaced by the instantiated object)
	// vars
	protected $__uid		= null;		// the value of this::key
	protected $__tainted	= false;	// whether data has been changed
	protected $__data		= null;		// the record for this object (created by get())

	// helper objects
	protected $__db			= null;

	/**
	 * __construct
	 *
	 * Record class constructor.
	 * @param integer $id if no id, we'll create a new record
	 * @param boolean $create we create a record by default if we don't have an id, unless $create is set to false
	 * @param array $foreign (optional) key=>val pairs containing reference keys
	 * @return object $this
	 */
	public function __construct(){
		parent::__construct();

		// gxpage classes
		$this->__db = $this->GXPage->Database->System;
		if( !$this->__db ){
			trigger_error( 'No database connection', E_USER_ERROR );
			return false;
		}

		// sense args
		$args = func_get_args();
		$argnames = array('id','foreign');
		// unique identifier for this record
		$id = null;
		// array containing values for foreign key columns
		$foreign = null;
		// attempt to load foreign keys? false will return this record without trying to instantiate foreign key columns
		$load = true;
		// create a record if there's no id?
		$create = true;
		// map provided args to their names
		foreach( $args as $a ){
			switch(true){
				case $a === GXRecord::NO_KEYS:
					$load = false;
					continue;
					break;
				case $a === GXRecord::NO_CREATE:
					$create = false;
					continue;
					break;
				default:
					$name = array_shift($argnames);
					${$name} = $a;
			}
		}

		// if id is either boolean or not scalar, unset it
		if( !is_scalar($id) || is_bool($id) ){
			$id = null;
		}

		// if we have an id, load the record
		if( $id ){
			// if we can't find the record, set everything to null
			if( !$this->get($id) ){
				// load data into this::uid and this::data
				$this->{$this->__key}	= null;
				$this->__uid			= null;
				$this->__data			= null;
				trigger_error( 'There is no record with that id (' .$id .'), setting everything to null for ' .$this->__table, E_USER_ERROR );
			}
		}
		// if no id and we're creating, create now
		if( !$id && $create ){
			$this->create($foreign);
		}
		// load foreign keys
		if( $load ){
			$this->loadForeignKeys($foreign,$create);
		}

		// return self
		return $this;
	}

	/**
	 * setParam
	 *
	 * Sets the value of the provided parameter.
	 * @param string $name the name of the parameter, minus the leading underscores
	 * @param string $value
	 * @return boolean
	 */
	public function setParam( $name = null, $value = null ){
		if( !strlen($name) || !strlen($value) ){
			trigger_error( 'Missing name (' .$name .') or value (' .$value .')', E_USER_ERROR );
			return false;
		}
		$var = '__' .$name;
		if( !isset($this->$var) ){
			trigger_error( 'No class var by that name (' .$var .')', E_USER_ERROR );
			return false;
		}
		$this->$var = $value;
		return true;
	}

	/**
	 * getForeign
	 *
	 * Returns the foreign object if it's an object, or false.
	 * @param string $key the name of the foreign key (column)
	 * @return object or false on failure
	 */
	public function getForeign($key = null){
		return (is_object($this->__foreign[$key]) ? $this->__foreign[$key] : false);
	}

	/**
	 * loadForeign
	 *
	 * If the foreign object for the specified key is not already instantiated,
	 * this instantiates it and returns it.
	 * @param string $key the name of the column for which to load the object
	 * @param boolean if not already instantiated, create a record for it?
	 * @param array $foreign key=>value pairs of column=>classnames to instantiate
	 * @return object or false on failure
	 */
	public function loadForeign($key = null, $create = false, $foreign = null){
		if(is_object($this->getForeign($key, $create))){
			trigger_error($key .' is already attached', E_USER_WARNING);
			$this->$key = $this->getForeign($key)->getKey();
		}
		// grab the current value for the foreign column
		$val = $this->$key;
		// if we don't have a val and we're not creating, don't instantiate this foreign
		if( !strlen($val) && !$create ){
			trigger_error( 'No value for ' .$key .' in ' .get_class($this) .' and not creating ... skipping', E_USER_WARNING );
			return false;
		}
		// instantiate the provided class name and place it in this::__foreign[$col]
		$class = $this->__foreign[$key];
		$this->__foreign[$key] =& new $class($val, $foreign, ($create ? null : GXRecord::NO_CREATE));
		// sanity check ... must have an object, and the object must have the necessary class vars
		if( !is_object($this->__foreign[$key]) || !$this->__foreign[$key]->__table || !$this->__foreign[$key]->__node || !$this->__foreign[$key]->__key || !$this->__foreign[$key]->__sql ){
			trigger_error( 'Unable to instantiate ' .$class .' or missing required class vars in ' .get_class($this->__foreign[$key]) .' __table=' .$this->__foreign[$key]->__table .', __node=' .$this->__foreign[$key]->__node .', __key=' .$this->__foreign[$key]->__key .', __sql=' .$this->__foreign[$key]->__sql, E_USER_ERROR );
			continue;
		}
		// place the new unique key into the calling column
		$this->$key = $this->getForeign($key)->getKey();
		// return the new object
		return $this->__foreign[$key];
	}

	/**
	 * loadForeignKeys
	 *
	 * Loops thru all class-defined or passed ($foreign) foreign columns,
	 * instantiating and returning as necessary.
	 * @param array $foreign (optional) key=>value pairs of column=>classname, passed manually
	 * @param boolean $create whether to create records if there's no value for the foreign column
	 * @return boolean
	 */
	public function loadForeignKeys($foreign = null, $create = true){
		// check for foreign keys
		if( !is_array($this->__foreign) || !count($this->__foreign) ){
			trigger_error( 'No provided foreign keys', E_USER_WARNING );
			return false;
		}

		// run through foreign keys and create them also
		if( count($this->__foreign) ){
			// loop thru provided foreign keys
			foreach( $this->__foreign as $col => $class ){
				$this->loadForeign($col, $create, $foreign);
			}
		}

		// return for the whole operation
		return true;
	}

	/**
	 * __destruct
	 *
	 * If there are changes to the internal data for this object, writes them back to the database.
	 * @return boolean
	 */
	public function __destruct(){
		// do nothing if we have no changes
		if( !$this->__tainted ){
			trigger_error( 'GXRecord destructor: no changes for ' .$this->__table .'.' .$this->__key .'=' .$this->{$this->__key}, E_USER_WARNING );
			return true;
		}
		// write changes
		$numaffected = null;
		if( !($numaffected = $this->set()) ){
			trigger_error( 'Unable to write changes to database', E_USER_ERROR );
			return false;
		}
		trigger_error( 'GXRecord destructor: ' .$numaffected .' records affected (' .gettype($numaffected) .') for ' .$this->__table .'.' .$this->__key .'=' .$this->{$this->__key}, E_USER_WARNING );
		return $numaffected;
	}

	/************************************************
	 *                 display methods              *
	 ************************************************/

	/**
	 * xml
	 *
	 * Builds the entire record and returns it as a GXDOM object.
	 * @return object GXDOM
	 */
	public function xml(){
		// grab this record
		$info = $this->getData();

		// use the key as an attribute, so unset it from the record
		$atts = array(
			$this->__key => $this->{$this->__key}
			);
		unset($info->{$this->__key});
		// create the xml
		$xml = new GXDOM( $this->__node, $info, $atts );

		// attach foreign GXRecord objects
		if( count($this->__foreign) ){
			foreach( $this->__foreign as $col => $obj ){
				// skip foreign keys that aren't GXRecord objects yet
				if( !is_object($obj) ){
					continue;
				}
				// get the xml
				$objxml = null;
				if( !($objxml = $obj->xml()) ){
					trigger_error( 'Object ' .get_class($obj) .' has no xml', E_USER_WARNING );
					continue;
				}
				// add the foreign object's xml without a container
				$xml->foreign( $obj->xml()->node, GXDOM::NO_CONTAINER );
			}
		}

		// return the entire object
		return $xml;
	}

	/************************************************
	 *              magic record methods            *
	 ************************************************/

	/**
	 * __get
	 *
	 * Returns the named parameter from the stored record data.
	 * @param string $name
	 * @return mixed
	 */
	protected function __get( $name = null ){
		// first check for a top-level class var
		if( isset($this->$name) ){
			return $this->$name;
		}
		return (isset($this->__data->$name) ? $this->__data->$name : null);
	}

	/**
	 * __set
	 *
	 * Sets the value of the named parameter.
	 * @param string $name
	 * @param mixed $value
	 * @return boolean
	 */
	protected function __set( $name = null, $value = null ){
		// only mark tainted if the value is changing
		if( $this->$name != $value ){
			$this->__tainted = true;
		}
		return ( is_object($this->__data) ? $this->__data->$name = $value : null );
	}

	/************************************************
	 *              core record methods             *
	 ************************************************/

	/**
	 * create
	 *
	 * Creates a new record using the provided data.
	 * @param array $info col=>val array to send to GXDB
	 * @return integer the id of the new record
	 */
	protected function create( $data = null ){
		if( $this->{$this->__key} ){
			trigger_error( 'There is already a ' .$this->__table .' record for that ' .$this->__key .' (' .$this->{$this->__key} .')', E_USER_ERROR );
			return false;
		}
		// if no data, database will fail
		if( !$this->__create ){
			trigger_error( 'Missing info ... you must provide at least one column in this::__create', E_USER_ERROR );
			return false;
		}

		// check for required keys; those without a value in the this::__create array
		if( count($this->__create) ){
			// make sure we have all required columns to create the record
			foreach( $this->__create as $col => $val ){
				if(  !is_array($data) &&!strlen($val) && !strlen($data[$col]) ){
					trigger_error( 'Missing required column ' .$col .' in ' .get_class($this), E_USER_ERROR );
				}
				// fall back to the value provided in $foreign
				$val = (strlen($val)) ? $val : $data[$col];
				$this->__create[$col] = $val;
			}
		}

		// create the record
		$r = 0;
		if( !($r = $this->__db->set( $this->__table, $this->__create )) ){
			trigger_error( 'Unable to create the ' .$this->__table .' record for ' .print_r($this->__create,true) .'. Some databases require at least one column to be set (even if you have default values for all columns).', E_USER_ERROR );
			return false;
		}
		// if $r is an integer, it's the new id ... otherwise, treat it like a record
		if(is_numeric($r)){
			$id = $r;
			$r = array();
			$r[0] = new stdClass();
			$r[0]->{$this->__key} = $id;
		}
		// load the data just inserted
		if( !$this->get( $r[0]->{$this->__key} )){
			trigger_error( 'Uncaught error creating the ' .$this->__table .' record for ' .print_r($this->__create,true), E_USER_ERROR );
			return false;
		}
		trigger_error( 'Created a record for ' .get_class($this) .' using ' .$this->__key .'; got ' .$r[0]->{$this->__key}, E_USER_WARNING );
		return $r[0]->{$this->__key};
	}

	/**
	 * getKey
	 *
	 * Returns the value of this object's this::key attribute.
	 * @return mixed
	 */
	public function getKey(){
		return $this->{$this->__key};
	}

	/**
	 * get
	 *
	 * Returns the record.
	 * @param integer $id
	 * @return object
	 */
	protected function get( $id = null ){
		// if we have no id, try to derive it by examining the var name defined by this::key
		if( !$id ){
			if( is_null($this->__uid) ){
				trigger_error( $this->__key .' (' .$this->__uid .') is null ' .$this->__table, E_USER_ERROR );
				return false;
			}
			$id = $this->__uid;
		}
		// grab the database record
		$r = $this->getRecord($id);
		if( !$r ){
			trigger_error( 'Unable to get record', E_USER_ERROR );
			return false;
		}
		// load data into this::uid and this::data
		$this->{$this->__key}	= $r[0]->{$this->__key};
		$this->__uid			= $r[0]->{$this->__key};
		$this->__data			= $r[0];
		trigger_error( 'found ' .$this->__key .'=' .$this->{$this->__key} .' for ' .$this->__table, E_USER_WARNING );
		return $this->__data;
	}

	/**
	 * getRecord
	 *
	 * Runs the class SQL and returns its results.
	 * No checks, just runs the query.
	 * @param mixed $id the __key
	 * @return array or false on failure
	 */
	protected function getRecord( $id = null ){
		return $this->__db->get( $this->__sql, array($this->__key=>$id) );
	}

	/**
	 * getData
	 *
	 * Returns the data currently stored in the object.
	 * @return array
	 */
	public function getData(){
		return $this->__data;
	}

	/**
	 * flush
	 *
	 * Wrapper for set.
	 */
	public function flush( $info = null ){
		return $this->set($info);
	}

	/**
	 * refresh
	 *
	 * Wrapper for get.
	 */
	public function refresh(){
		return $this->get();
	}

	/**
	 * set
	 *
	 * Writes this::data back to the database for this record.
	 * @param array $info the list of col=>val pairs to update
	 * @return boolean
	 */
	protected function set( $info = null ){
		trigger_error( 'set called for ' .$this->__table .' using __uid=' .$this->__uid, E_USER_WARNING );
		// if the data hasn't changed, don't try to set it
		if(!$this->__tainted){
			trigger_error('set called, but no data has changed.', E_USER_WARNING);
			return true;
		}
		// if no id, error
		if( is_null($this->{$this->__key}) ){
			trigger_error( 'set called, but there is no record', E_USER_ERROR );
			return false;
		}
		// if we have info, only use columns which are part of info; otherwise, use everything in this::data
		$info = ($info) ? array_intersect_assoc($info,(array)$this->__data) : (array)$this->__data;

		// sanity checks
		if( !count($info) ){
			trigger_error( 'Missing info', E_USER_ERROR );
			return false;
		}
		if( !$this->{$this->__key} ){
			trigger_error( 'No value for this record\'s unique id', E_USER_ERROR );
			return false;
		}
		// make sure the unique key is in the array, since GXDB uses unique keys to build its update sql
		if( !isset($info[ $this->__key ]) ){
			$info[ $this->__key ] = $this->{$this->__key};
		}

		// run the update function in GXDB
		$numaffected = $this->__db->set( $this->__table, $info, GXDB::SQL_UPDATE );
		if( !is_numeric($numaffected) || !$numaffected ){
			trigger_error( 'No database records (' .( gettype($numaffected) == 'integer' ? $numaffected : 'error' ) .') were affected; could not record changes to this record (' .$this->__table .'.' .$this->__key .'=' .$this->{$this->__key} .')', E_USER_ERROR );
			return false;
		}
		trigger_error( 'Set ' .$numaffected .' records', E_USER_WARNING );

		// turn off tainted
		$this->__tainted = false;
		// update values in this object with those just stored
		$this->get();
		return $numaffected;
	}

	/**
	 * setData
	 *
	 * Updates stored data with provided info, then forces a set.
	 * @param array $info the list of col=>val pairs
	 * @return boolean
	 */
	public function setData( $info = null ){
		if( !is_array($info) || !count($info) ){
			trigger_error( 'Missing info', E_USER_ERROR );
			return false;
		}

		// set each column
		foreach( $info as $key => $val ){
			// make sure we don't overwrite the internal key
			if( $key == $this->__key ){
				continue;
			}
			$this->$key = $val;
		}

		// force update
		$this->__tainted = true;
		return $this->flush();
	}

}
?>